Skip to content

Add Init Hooks: Auto-Apply Seed Fixtures on Boot (#335 P1) - #339

Merged
thzgajendra merged 2 commits into
stackshy:developmentfrom
thzgajendra:feat/init-hooks
Aug 8, 2026
Merged

Add Init Hooks: Auto-Apply Seed Fixtures on Boot (#335 P1)#339
thzgajendra merged 2 commits into
stackshy:developmentfrom
thzgajendra:feat/init-hooks

Conversation

@thzgajendra

Copy link
Copy Markdown
Collaborator

Objective / Issue

P1 item of the "minikube for cloud resources" roadmap (#335). Bring the emulator up in a known state without manual seeding: drop *.json seed fixtures in an init directory and they're applied on every startup (docker-entrypoint.d-style).

What we found

  • seed.Load/seed.Apply already turn a declarative fixture into resources through the provider-agnostic driver interfaces, and serve already has a clean boot sequence (build providers → restore persistence → serve). So init hooks are a thin layer: read a directory of fixtures at the right point in boot.
  • Blast radius: additive. With no --init-dir (and no init.d), behavior is unchanged.

How we fixed it / How it works

  • serve --init-dir <dir>: after providers are built and any persisted state is restored, before serving, apply every *.json (lexical order) as a seed.Fixtures to all running providers. Fixtures are provider-agnostic, so one file seeds S3/Blob/GCS alike.
  • cloudemu start auto-loads <run-dir>/init.d when it exists (drop-in), unless the user passes an explicit --init-dir.
  • Failure policy: a malformed fixture fails startup (clear misconfiguration); a per-provider apply error — e.g. a resource that already exists from restored persistence — logs a warning and boot continues, so init can't wedge the stop→start path.
sequenceDiagram
    actor U as You
    participant S as serve (boot)
    participant D as init dir
    participant P as providers
    U->>D: drop 01-baseline.json (buckets/tables/secrets)
    U->>S: cloudemu start
    S->>S: build providers, restore persistence
    S->>D: read *.json (lexical order)
    S->>P: seed.Apply each fixture to every provider
    S-->>U: serving, already in the boot state
Loading

Alternatives not taken

  • Executable boot scripts (*.sh run post-ready with endpoint env) — the powerful but exec/security-heavy half of init hooks; deferred to a follow-up to keep this a small, safe change. Noted in the docs.
  • Apply to a single/primary provider — chose all-providers so one fixture yields a consistent baseline across clouds, matching the provider-agnostic fixture model.

Docs / Tests / Playground

  • Docs: docs/standalone-server.md — init-hooks section (drop-in usage, ordering, failure policy, scripts-deferred note).
  • Unit: applies fixtures in lexical order + ignores non-JSON; missing dir → no-op; malformed JSON → fails; duplicate (already-exists) → warns not fails.

Test plan

  • go test -race ./cmd/cloudemu/...
  • Full local CI: gofmt / build / vet / go test ./... / go mod tidy / golangci-lint = clean (CodeQL: only pre-existing findings, none in changed files).
  • Live E2E via cloudemu start + real aws CLI on the default ~/.cloudemu: dropped init.d/01-baseline.json (bucket+object, table+item, secret) → start → emulator booted straight into that state (config.yaml = env: local, DynamoDB item Ada, secret s3cr3t) with no manual seeding; restart re-applies cleanly.

Risk & Rollback

  • Low: additive and opt-in (no --init-dir / no init.d = unchanged). Malformed fixtures fail fast with a clear error; apply errors are non-fatal warnings.

Conclusion

cloudemu start now brings the local cloud up pre-seeded from a drop-in init.d, reusing the existing fixture engine.

Follow-up (#335): executable boot scripts (post-ready, endpoint env) for setup beyond the fixture schema.

Bring the emulator up in a known state without manual seeding: drop *.json seed
fixtures in an init directory and they're applied on every startup.

- serve --init-dir <dir>: after providers are built and any persisted state is
  restored, before serving, apply every *.json (lexical order) as a seed
  fixture to all running providers. A parse error fails startup; a per-provider
  apply error (e.g. a resource that already exists from restored state) logs a
  warning and boot continues.
- lifecycle start auto-loads <run-dir>/init.d when it exists (drop-in), unless
  the user passes an explicit --init-dir.

Fixtures are provider-agnostic, so one file seeds S3/Blob/GCS alike. Running
setup scripts on boot is a planned follow-up.

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review — init hooks

Small, clean, well-placed feature. Verified in an isolated worktree at the PR head; gates green (build + GOOS=windows go build ./cmd/cloudemu / vet / go test -race ./cmd/cloudemu/ ok / gofmt clean). Applied at the right point (after build + persistence-restore, before serving), iterates all providers per the provider-agnostic fixture model, no concurrency concern (boot is single-threaded before Serve), and start auto-loads <run-dir>/init.d unless --init-dir is given.

Holding on one Medium (silent-correctness) plus a Low note.

M1 (Medium) — a colliding resource silently truncates the rest of a fixture

applyInitFile warns-and-continues per file, but seed.Apply is category-level fail-fast: applyBuckets returns on the first CreateBucket error, and Apply then skips all tables/secrets/instances. So the first already-exists resource aborts everything after it in that fixture, while boot proceeds reporting only a warning. Two realistic silent-loss paths:

  • Two overlapping init files, no --persist: 00-base.json creates bucket X; 01-app.json has bucket X (dup) + table YY is never created, boot looks fine.
  • --persist + init.d overlap: restored state collides with a fixture's early resource → any new resource later in that same file is silently dropped on restart.

The "duplicate → warns not fails" policy (and TestApplyInitDirDuplicateWarnsNotFails, which has a single bucket) implies graceful per-resource skipping that isn't there — a duplicate truncates the fixture from that point. Fix: apply init idempotently at resource granularity (skip AlreadyExists and keep going — e.g. a best-effort / IgnoreExisting mode on seed.Apply, or pre-filter existing resources). At minimum, document that a fixture aborts at the first existing resource, and add a test asserting a post-collision resource still applies. Inline below.

L1 (Low) — gosec G703 on os.Stat(path) (lifecycle.go:77)

Taint from the --home-derived path — same linter-version drift as the earlier lifecycle PRs (newer bundled gosec; your --new-from-rev reports 0), and it's the user's own --home, not a trust boundary. Non-blocking; a //nolint:gosec closes it if the pinned linter ever flags it.

No AI attribution. Requesting changes on M1; L1 is a non-blocking note.

Comment thread cmd/cloudemu/serve.go Outdated
}

for prov, t := range targets {
if err := seed.Apply(ctx, f, t); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

M1 (Medium). seed.Apply is category-level fail-fast — applyBuckets returns on the first CreateBucket that errors (e.g. AlreadyExists), and Apply then never reaches tables/secrets/instances. Since this loop only warns per provider/file, the first colliding resource silently drops every resource after it in the fixture, yet boot continues "successfully." Triggers without any exotic setup: two init files that overlap on an early resource (bucket X in 00, then X+table Y in 01Y lost), or --persist restoring a resource that an init fixture re-declares before a new one. Make init application idempotent at resource granularity — skip AlreadyExists and continue (a best-effort/IgnoreExisting mode on seed.Apply, or pre-filter what already exists) — so a duplicate skips just that resource, matching the documented "warn and continue." Please also extend TestApplyInitDirDuplicateWarnsNotFails with a second resource after the duplicate and assert it still gets created.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. seed.Apply now takes an IgnoreExisting() option that skips a resource returning AlreadyExists and continues, instead of the category-level fail-fast that truncated everything after the first collision. Init application (applyInitFile) uses it, so a duplicate skips just that resource and the rest of the fixture still applies — matching the documented "warn and continue". A bucket/table that already exists still has its declared objects/items applied; secrets/instances that exist are skipped.

Extended TestApplyInitDirDuplicateWarnsNotFails: the fixture is now bucket dup (pre-created → collision) followed by table fresh, and the test asserts fresh is created despite the collision. Also E2E-verified: --persist restores a bucket, init.d re-declares it (collision), and a table declared after it is still created on restart. 0c12468

…eview)

A duplicate resource previously truncated the rest of a fixture: seed.Apply is
category-level fail-fast, so the first AlreadyExists aborted everything after it
while boot only logged a warning — silent data loss when two init files overlap
or an init fixture re-declares restored state.

- seed.Apply gains an IgnoreExisting option that skips AlreadyExists per
  resource and continues, instead of failing the whole fixture.
- Init application uses it, so a colliding resource skips just itself and the
  rest of the fixture still applies.
- Extend the duplicate test with a resource after the collision, asserting it
  is still created.
@thzgajendra

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — M1 fixed in 0c12468.

  • M1 (init truncation): added seed.IgnoreExisting() so init application is idempotent at resource granularity — a colliding resource is skipped and the rest of the fixture still applies (no more silent truncation from a category-level fail-fast). New test asserts a resource declared after a collision is created; E2E-verified against the --persist + init.d overlap case.
  • L1 (gosec G703 on the --home os.Stat): leaving as-is — same linter-version-drift false positive as the earlier lifecycle PRs (the --home path is a user CLI arg, not a trust boundary; the pinned/CI-advisory linter doesn't fire it, so a //nolint:gocritic would itself be flagged as an unused directive). Happy to add the //nolint if the repo bumps to a gosec version that flags it.

Gate green: build / vet / gofmt / go test -race ./seed/... ./cmd/cloudemu/... / golangci-lint --new-from-rev = 0.

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — M1 resolved

Verified the fix (0c124680) in an isolated worktree at the PR head; gate green (build + GOOS=windows go build ./cmd/cloudemu / vet / go test -race ./seed/... ./cmd/cloudemu/ ok / gofmt clean).

M1 (a colliding resource truncating the rest of a fixture) is fully resolved, and cleanly:

  • seed.Apply gains an additive variadic ...Option with IgnoreExisting(). The two production callers are exactly right — the /seed endpoint (serve.go:253) passes no opts and keeps its old fail-fast behavior, while init (serve.go:512) opts in — so persist-restore and the seed admin path have zero blast radius from the shared-package change.
  • On an AlreadyExists (detected via cerrors.IsAlreadyExists), each of applyBuckets/applyTables/applySecrets/applyInstances skips just that resource and continues, and buckets/tables still (re)put their declared objects/items. Genuine PutItem/PutObject errors still fail hard.
  • TestApplyInitDirDuplicateWarnsNotFails now uses a fixture where the first resource collides and a new one (fresh) follows, asserting fresh is still created — exactly the post-collision-still-applies case that was missing.

One non-blocking Low remains: gosec G703 on lifecycle.go:77 (os.Stat on the --home-derived path) is the same linter-version-drift false-positive as the earlier lifecycle PRs — a //nolint:gosec closes it if your pinned linter ever flags it.

Clean, well-architected fix with a proving test. LGTM.

@thzgajendra
thzgajendra merged commit 33a8568 into stackshy:development Aug 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants